--- import Layout from '../../layouts/Layout.astro'; import ProductCard from '../../components/ProductCard.astro'; import ProductPriceSection from '../../components/ProductPriceSection.tsx'; import PriceHistoryChart from '../../components/PriceHistoryChart.tsx'; import WatchlistButtonStatic from '../../components/WatchlistButtonStatic.astro'; import PriceAlertForm from '../../components/PriceAlertForm.tsx'; import QuickFacts from '../../components/QuickFacts.astro'; import { getProductBySlug, getProductsFromDB, getRelatedProducts, getForYouOnPDP, getRecentlyViewedOnPDP, getBrandBySlugFromDB, getModelsByBrand, getProductTags, getProductOffersLastUpdated } from '../../lib/products'; import { getBrandLogo } from '../../components/BrandLogos'; import { buildDescription } from '../../lib/product-description'; import Breadcrumb from '../../components/Breadcrumb.astro'; import { lookupSlugRedirect } from '../../lib/slug-redirects'; import { query } from '../../lib/db'; import { getUserFromRequest } from '../../lib/user-auth'; import { trackProductViewed } from '../../lib/klaviyo'; function getDiscountPercent(sneaker: { originalPrice: number; lowestPrice: number }): number { return Math.round(((sneaker.originalPrice - sneaker.lowestPrice) / sneaker.originalPrice) * 100); } const { slug } = Astro.params; const spSize = Astro.cookies.get('sp_size')?.value || undefined; const sneaker = await getProductBySlug(slug!); if (!sneaker) { const newSlug = await lookupSlugRedirect(slug!); if (newSlug) return Astro.redirect(`/sneakers/${newSlug}`, 301); return Astro.redirect('/sneakers'); } // Track product view in Klaviyo for logged-in users const user = getUserFromRequest(Astro.request); if (user) { trackProductViewed(user.email, sneaker).catch(err => console.error('Failed to track product view in Klaviyo:', err) ); } const displayName = sneaker.displayName || `${sneaker.brand} ${sneaker.name}`; const discount = getDiscountPercent(sneaker); const { variants, moreBrand } = await getRelatedProducts(Number(sneaker.id), sneaker.brandNorm || sneaker.brand.toLowerCase(), sneaker.modelNorm || null); const forYou = await getForYouOnPDP(user?.id || null, Number(sneaker.id), sneaker.brandNorm || sneaker.brand.toLowerCase()); const recentlyViewed = await getRecentlyViewedOnPDP(user?.id || null, 3); const brandInfo = await getBrandBySlugFromDB(sneaker.brandSlug); // Build subtype page URL if product has a model const modelSlugStr = sneaker.model ? sneaker.model.replace(/[^a-zA-Z0-9]+/g, '-').replace(/(^-|-$)/g, '').toLowerCase() : null; const modelPageUrl = modelSlugStr ? `/merken/${sneaker.brandSlug}/${modelSlugStr}` : null; // Popular models from this brand (for internal linking) const brandModels = await getModelsByBrand(sneaker.brandSlug); const popularModels = brandModels .filter(m => m.slug !== modelSlugStr) .slice(0, 8); // Build canonical URL pointing to the color page const colorSlug = sneaker.colorway ? sneaker.colorway.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') : null; const canonicalColorUrl = modelPageUrl && colorSlug ? `${modelPageUrl}/${colorSlug}` : undefined; // Price trend: check if price has been rising or falling over last 30 days let priceTrend: 'falling' | 'rising' | null = null; try { const historyResult = await query<{ recorded_at: Date; price: string }>(` SELECT recorded_at, MIN(price) as price FROM price_history WHERE product_id = $1 AND recorded_at >= NOW() - INTERVAL '30 days' GROUP BY recorded_at ORDER BY recorded_at ASC LIMIT 30 `, [sneaker.id]); const rows = historyResult.rows; if (rows.length >= 2) { const firstPrice = parseFloat(rows[0].price); const lastPrice = parseFloat(rows[rows.length - 1].price); const changePct = ((lastPrice - firstPrice) / firstPrice) * 100; if (changePct < -5) priceTrend = 'falling'; else if (changePct > 5) priceTrend = 'rising'; } } catch (_) { // price_history may not exist yet — silently skip } // Deal Score calculation const shops = sneaker.offers.filter(o => o.inStock).length; const discountPts = discount >= 40 ? 4 : discount >= 30 ? 3 : discount >= 20 ? 2 : discount >= 10 ? 1 : 0; const shopPts = shops >= 5 ? 2 : shops >= 3 ? 1 : 0; const rawScore = discountPts + shopPts; const dealScore = rawScore > 0 ? Math.round((rawScore / 6) * 9 + 1) : 1; const dealScoreLabel = dealScore >= 8 ? 'Top deal' : dealScore >= 5 ? 'Goed' : 'Check prijs'; const dealScoreClass = dealScore >= 8 ? 'bg-green-500/15 text-green-400 border-green-500/30' : dealScore >= 5 ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : 'bg-red-500/10 text-red-400/70 border-red-500/20'; const dealScoreTooltip = [ `Deal Score: ${dealScore}/10`, discountPts > 0 ? `${discount}% korting (+${discountPts} punt${discountPts > 1 ? 'en' : ''})` : 'Geen korting (0 punten)', `${shops} shop${shops !== 1 ? 's' : ''} (+${shopPts} punt${shopPts !== 1 ? 'en' : ''})`, dealScore >= 8 ? 'Uitstekende deal — nu kopen!' : dealScore >= 5 ? 'Goede deal' : 'Vergelijk prijzen voor je koopt', ].join(' · '); const viewItemData = { item_id: sneaker.id, item_name: displayName, item_brand: sneaker.brand, price: sneaker.lowestPrice, item_category: sneaker.name, }; const inStockOffers = sneaker.offers.filter(o => o.inStock); const offerCount = inStockOffers.length || 1; const highPrice = inStockOffers.length > 0 ? Math.max(...inStockOffers.map(o => o.price)) : sneaker.lowestPrice; const cheapestOffer = inStockOffers.slice().sort((a, b) => a.price - b.price)[0]; const productTags = await getProductTags(Number(sneaker.id)); const offersLastUpdated = await getProductOffersLastUpdated(Number(sneaker.id)); const dateModifiedIso = (offersLastUpdated ?? new Date()).toISOString(); const priceValidUntilIso = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const merchantReturnPolicy = { "@type": "MerchantReturnPolicy", applicableCountry: "NL", returnPolicyCategory: "https://schema.org/MerchantReturnFiniteReturnWindow", merchantReturnDays: 14, returnMethod: "https://schema.org/ReturnByMail", returnFees: "https://schema.org/FreeReturn", }; const builtDescription = await buildDescription({ productId: Number(sneaker.id), displayName, brand: sneaker.brand, brandSlug: sneaker.brandSlug, model: sneaker.model || null, modelSlug: modelSlugStr, colorway: sneaker.colorway || null, gender: sneaker.gender || null, lowestPrice: sneaker.lowestPrice, originalPrice: sneaker.originalPrice, shopsInStock: shops, cheapestShop: cheapestOffer?.shop, tags: productTags, discount, }); const schema = { "@context": "https://schema.org", "@type": "Product", inLanguage: "nl-NL", name: displayName, description: builtDescription.text, image: sneaker.image, brand: { "@type": "Brand", name: sneaker.brand }, ...(sneaker.sizes.length > 0 ? { sku: sneaker.sizes[0] } : {}), dateModified: dateModifiedIso, publisher: { "@type": "Organization", name: "SneakerPicks", url: "https://sneakerpicks.nl", logo: { "@type": "ImageObject", url: "https://sneakerpicks.nl/logo.svg" }, }, offers: { "@type": "AggregateOffer", lowPrice: sneaker.lowestPrice > 0 ? sneaker.lowestPrice : undefined, highPrice: highPrice > 0 ? highPrice : undefined, priceCurrency: "EUR", offerCount: offerCount, availability: inStockOffers.length > 0 ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", url: `https://sneakerpicks.nl/sneakers/${sneaker.slug}`, priceValidUntil: priceValidUntilIso, hasMerchantReturnPolicy: merchantReturnPolicy, offers: inStockOffers.slice(0, 10).map(o => ({ "@type": "Offer", price: o.price, priceCurrency: "EUR", availability: "https://schema.org/InStock", url: o.url, priceValidUntil: priceValidUntilIso, hasMerchantReturnPolicy: merchantReturnPolicy, seller: { "@type": "Organization", name: o.shop }, })), }, }; const breadcrumbSchema = { "@context": "https://schema.org", "@type": "BreadcrumbList", inLanguage: "nl-NL", itemListElement: [ { "@type": "ListItem", position: 1, name: "Home", item: "https://sneakerpicks.nl/" }, { "@type": "ListItem", position: 2, name: "Sneakers", item: "https://sneakerpicks.nl/sneakers" }, { "@type": "ListItem", position: 3, name: sneaker.brand, item: `https://sneakerpicks.nl/merken/${sneaker.brandSlug}` }, { "@type": "ListItem", position: 4, name: displayName }, ], }; // FAQ data for rich snippets const cheapestShop = sneaker.offers.filter(o => o.inStock).sort((a, b) => a.price - b.price)[0]; const faqItems: { question: string; answer: string }[] = []; if (cheapestShop) { faqItems.push({ question: `Wat is de laagste prijs voor de ${displayName}?`, answer: `De laagste prijs voor de ${displayName} is momenteel €${sneaker.lowestPrice.toFixed(2).replace('.', ',')} bij ${cheapestShop.shop}. We vergelijken prijzen bij ${shops} shops zodat je altijd de beste deal vindt.`, }); } if (shops > 0) { faqItems.push({ question: `Bij hoeveel shops is de ${displayName} beschikbaar?`, answer: `De ${displayName} is momenteel beschikbaar bij ${shops} ${shops === 1 ? 'shop' : 'shops'}. SneakerPicks vergelijkt automatisch alle prijzen zodat je direct ziet waar deze sneaker het goedkoopst is.`, }); } if (discount > 0) { faqItems.push({ question: `Is de ${displayName} in de sale?`, answer: `Ja! De ${displayName} is momenteel ${discount}% afgeprijsd. De originele prijs is €${sneaker.originalPrice.toFixed(2).replace('.', ',')} en de laagste prijs nu is €${sneaker.lowestPrice.toFixed(2).replace('.', ',')}.`, }); } else { faqItems.push({ question: `Is de ${displayName} in de sale?`, answer: `De ${displayName} wordt momenteel aangeboden tegen de reguliere prijs. Stel een prijsalert in om een melding te krijgen zodra de prijs daalt.`, }); } if (sneaker.sizes.length > 0) { const sizeList = sneaker.sizes.slice(0, 10).join(', '); const moreSizes = sneaker.sizes.length > 10 ? ` en ${sneaker.sizes.length - 10} andere maten` : ''; faqItems.push({ question: `Welke maten zijn beschikbaar voor de ${displayName}?`, answer: `De ${displayName} is beschikbaar in EU maten ${sizeList}${moreSizes}. Beschikbaarheid verschilt per shop — bekijk de prijsvergelijking hierboven voor actuele voorraad per maat.`, }); } if (priceTrend) { faqItems.push({ question: `Daalt of stijgt de prijs van de ${displayName}?`, answer: priceTrend === 'falling' ? `De prijs van de ${displayName} is de afgelopen 30 dagen gedaald. Dit is een goed moment om te kopen! Bekijk de prijsgrafiek voor het volledige verloop.` : `De prijs van de ${displayName} is de afgelopen 30 dagen gestegen. Stel een prijsalert in om een melding te krijgen als de prijs weer daalt.`, }); } // Price history: 30-day stats for HTML table (AI-parseable) const now = Date.now(); const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; const history30 = (sneaker.priceHistory || []).filter(h => { const t = new Date(h.date).getTime(); return !isNaN(t) && now - t <= thirtyDaysMs; }); let priceHistoryStats: { low: number; high: number; avg: number; days: number; windowStart: string; windowEnd: string } | null = null; if (history30.length >= 2) { const prices = history30.map(h => h.price).filter(p => p > 0); if (prices.length >= 2) { const low = Math.min(...prices); const high = Math.max(...prices); const avg = prices.reduce((s, p) => s + p, 0) / prices.length; priceHistoryStats = { low, high, avg: Math.round(avg * 100) / 100, days: prices.length, windowStart: history30[0].date, windowEnd: history30[history30.length - 1].date, }; } } const formatDateNL = (d: string) => { const date = new Date(d); return isNaN(date.getTime()) ? d : date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' }); }; const formatEuro = (n: number) => `€${n.toFixed(2).replace('.', ',')}`; // QuickFacts — quotable sentences for AI citations const quickFactsUpdatedLabel = offersLastUpdated ? new Date(offersLastUpdated).toLocaleString('nl-NL', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : new Date().toLocaleString('nl-NL', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const quickFactsLowestPriceStr = `€${sneaker.lowestPrice.toFixed(2).replace('.', ',')}`; const quickFactsSummary = cheapestShop ? `Laagste prijs vandaag: ${quickFactsLowestPriceStr} bij ${cheapestShop.shop} · ${shops} ${shops === 1 ? 'winkel' : 'winkels'} vergeleken · Laatst bijgewerkt: ${quickFactsUpdatedLabel}.` : `${shops} ${shops === 1 ? 'winkel' : 'winkels'} vergeleken · Laatst bijgewerkt: ${quickFactsUpdatedLabel}.`; const quickFacts: { label: string; value: string }[] = [ { label: 'Laagste prijs', value: quickFactsLowestPriceStr }, ...(cheapestShop ? [{ label: 'Bij', value: cheapestShop.shop }] : []), { label: 'Winkels', value: String(shops) }, ...(discount > 0 ? [{ label: 'Korting', value: `${discount}%` }] : []), { label: 'Bijgewerkt', value: quickFactsUpdatedLabel }, ]; const faqSchema = faqItems.length > 0 ? { "@context": "https://schema.org", "@type": "FAQPage", inLanguage: "nl-NL", mainEntity: faqItems.map(item => ({ "@type": "Question", name: item.question, acceptedAnswer: { "@type": "Answer", text: item.answer, }, })), } : null; --- = 15 ? ` -${discount}%` : ''} vanaf €${sneaker.lowestPrice.toFixed(2).replace('.', ',')} | Vergelijk ${shops} ${shops === 1 ? 'winkel' : 'winkels'} | SneakerPicks`} ogImage={sneaker.image} description={`${displayName}${sneaker.colorway ? ` (${sneaker.colorway})` : ''} kopen? Vergelijk prijzen bij ${shops} Nederlandse webshops. Laagste prijs: €${sneaker.lowestPrice.toFixed(2).replace('.', ',')}${discount >= 15 ? `. Nu ${discount}% korting!` : '. Gratis prijsalerts.'}`} canonical={canonicalColorUrl}>
{discount > 0 && (
-{discount}%
)}
{`${displayName} o.inStock).length} winkels`} width="600" height="600" class="h-full w-full object-contain" loading="eager" fetchpriority="high" decoding="async" onerror="this.style.display='none'" />
{(() => { const inlineSvg = getBrandLogo(sneaker.brandSlug); if (inlineSvg) return

{sneaker.displayName || sneaker.name}

{sneaker.colorway} {modelPageUrl && ( · Alle {sneaker.model} kleuren )}

€{sneaker.lowestPrice.toFixed(2).replace('.', ',')} {sneaker.originalPrice > sneaker.lowestPrice && ( €{sneaker.originalPrice.toFixed(2).replace('.', ',')} )} {priceTrend === 'falling' && ( Prijs daalt )} {priceTrend === 'rising' && ( Prijs stijgt )}
{sneaker.offers.length > 0 && (
Laagste prijs bij {sneaker.offers.sort((a, b) => a.price - b.price)[0].shop}
)} {/* Deal Score badge */} {rawScore > 0 && (
Deal Score {dealScore}/10 — {dealScoreLabel}
)} {/* Quick Facts — quotable sentences for AI citations */}
Delen Deel
{sneaker.gender === 'heren' ? 'Heren' : sneaker.gender === 'dames' ? 'Dames' : 'Unisex'} {sneaker.releaseDate && !isNaN(new Date(sneaker.releaseDate).getTime()) && ( {new Date(sneaker.releaseDate).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })} )} {sneaker.offers.filter(o => o.inStock).length} shops op voorraad

Over de {displayName}

{builtDescription.text}
sneaker.lowestPrice ? sneaker.originalPrice : undefined} /> {priceHistoryStats && (
Prijsstatistieken laatste 30 dagen ({formatDateNL(priceHistoryStats.windowStart)} – {formatDateNL(priceHistoryStats.windowEnd)}, {priceHistoryStats.days} {priceHistoryStats.days === 1 ? 'meetdag' : 'meetdagen'})
Statistiek Waarde
Laagste prijs (30 dagen) {formatEuro(priceHistoryStats.low)}
Gemiddelde prijs (30 dagen) {formatEuro(priceHistoryStats.avg)}
Hoogste prijs (30 dagen) {formatEuro(priceHistoryStats.high)}
Prijs vandaag {formatEuro(sneaker.lowestPrice)}
)}

Beschikbare maten

{sneaker.sizes.map(size => { const available = sneaker.offers.some(o => o.inStock && o.sizes.includes(size)); return ( EU {size} ); })}
{variants.length > 0 && (

Andere kleuren

{modelPageUrl && ( Bekijk alle varianten → )}
{variants.map((s, i) => ( ))}
)} {moreBrand.length > 0 && (

Meer van {sneaker.brand}

{moreBrand.map((s, i) => ( ))}
)} {popularModels.length > 0 && (

Populaire {sneaker.brand} modellen

{popularModels.map(m => ( {sneaker.brand} {m.name} ({m.count}) ))}
)} {forYou.length > 0 && (

Meer voor jou

Gebaseerd op je voorkeuren

{forYou.map((s, i) => ( ))}
)} {recentlyViewed.length > 0 && (

Onlangs bekeken

{recentlyViewed.map((s, i) => ( ))}
)} {faqItems.length > 0 && (

Veelgestelde vragen

{faqItems.map((item, i) => (
{item.question}
{item.answer}
))}
)}